﻿module Fable.Transforms.Fable2Php

open System
open FSharp.Compiler.Symbols
open Fable
open Fable.AST
open Fable.AST.Php
open Fable.Core
open System.Collections.Generic

type IPhpCompiler =
    inherit Compiler

    abstract GetEntityName: Fable.Entity -> string

    abstract PhpNamespace: string
    abstract MakeUniqueVar: string -> string
    abstract AddUse: PhpType -> unit
    abstract AddType: Fable.EntityRef option * PhpType -> unit
    abstract AddImport: string * bool -> unit
    abstract AddRequire: PhpType -> unit
    abstract AddRequire: string -> unit
    abstract AddLocalVar: string * bool -> unit
    abstract UseVar: Capture -> unit
    abstract UseVar: string -> unit
    abstract UseVarByRef: string -> unit
    abstract SetPhpNamespace: string -> unit
    abstract AddEntityName: Fable.Entity * string -> unit
    abstract ClearRequire: string -> unit
    abstract NewScope: unit -> unit
    abstract RestoreScope: unit -> Capture list
    abstract TryFindType: Fable.EntityRef -> Result<PhpType, Fable.Entity>
    abstract TryFindType: string -> PhpType option
    abstract IsThisArgument: Fable.Ident -> bool
    abstract IsImport: string -> bool option
    abstract DecisionTargets: (Fable.Ident list * Fable.Expr) list
    abstract SetDecisionTargets: (Fable.Ident list * Fable.Expr) list -> unit
    abstract SetThisArgument: string -> unit
    abstract ClearThisArgument: unit -> unit
    abstract Require: (string option * string) list
    abstract NsUse: PhpType list
    abstract EnterBreakable: string option -> unit
    abstract LeaveBreakable: unit -> unit
    abstract FindLableLevel: string -> int


module PhpUnion =
    let fSharpUnion =
        {
            Namespace = None
            Name = "FSharpUnion"
            Fields = []
            Constructor = None
            Methods = []
            Abstract = true
            BaseType = None
            Interfaces = []
            File = "fable-library/FSharp.Core.php"
            OriginalFullName = "FSharp.Core.FSharpUnion"
        }

module Core =
    let icomparable =
        {
            Namespace = None
            Name = "IComparable"
            Fields = []
            Constructor = None
            Methods = []
            Abstract = true
            BaseType = None
            Interfaces = []
            File = "fable-library/FSharp.Core.php"
            OriginalFullName = "System.Collections.IComparable"
        }


let fixExt path =
    Path.ChangeExtension(path, Path.GetExtension(path).Replace("js", "php").Replace("fs", "fs.php"))

let rec convertType (com: IPhpCompiler) (t: Fable.Type) =
    match t with
    | Fable.Type.Number(Int32, _) -> "int"
    | Fable.Type.String -> "string"
    | Fable.DeclaredType(ref, args) ->
        let ent = com.GetEntity(ref)
        com.GetEntityName(ent)


    | Fable.Type.List t -> convertType com t + "[]"

    | _ -> ""

/// regex to replace '$' sign that is illegal in Php to '_'. It also convert spaces '$0020' as '_'
let private charCodeEx = System.Text.RegularExpressions.Regex(@"(\$(0020)?|[\.`])")

/// fixes names generated by fable to be php safe
let private fixName (name: string) =
    match charCodeEx.Replace(name, "_") with
    | "empty" -> "_empty" // empty is a keyword in php and cannot be used in other contexts.
    | n -> n

let rec convertTypeToPhp (com: IPhpCompiler) (fableType: Fable.Type) =
    let withGenericParameters name =
        PhpNewArray(
            [
                PhpArrayNoIndex, PhpConst(PhpConstString name)
                for arg in fableType.Generics do
                    PhpArrayNoIndex, convertTypeToPhp com arg
            ]
        )

    let constType name = PhpConst(PhpConstString name)

    match fableType with
    | Fable.Type.Number(kind, _) -> constType (kind.ToString())
    | Fable.Type.String -> constType "String"
    | Fable.DeclaredType(ref, args) ->
        let ent = com.GetEntity(ref)
        let name = fixName (com.GetEntityName ent)

        if ent.GenericParameters.Length > 0 then
            withGenericParameters name
        else
            constType (sprintf "\%s\%s" com.PhpNamespace (fixName (ent.FullName.Replace(com.PhpNamespace + ".", ""))))
    | Fable.Type.List t -> withGenericParameters "List"
    | Fable.Tuple _ -> withGenericParameters "Tuple"
    | _ -> constType (sprintf "??? '%A'" fableType)

let getTypeReflectionMethodsForFields (com: IPhpCompiler) (fields: Fable.Field list) =
    [
        for field in fields do
            {
                PhpFun.Name = sprintf "get_%s_Type" field.Name
                PhpFun.Args = []
                PhpFun.Matchings = []
                PhpFun.Static = true
                PhpFun.Body = [ PhpStatement.PhpReturn(convertTypeToPhp com field.FieldType) ]
            }
    ]

/// generate name for DU case
/// For single case union, it just take the name
/// For multicase unions, it prepend the DU name (The same case name can be defined
/// in multiple DUs. In F# it is disambiguated by prefixing - DU.Case - This cannot
/// be done in Php)
let caseName (com: IPhpCompiler) (entity: Fable.Entity) (case: Fable.UnionCase) =
    if entity.UnionCases.Length = 1 then
        case.Name
    else
        com.GetEntityName entity + "_" + case.Name

let caseNameWithNamespace (com: IPhpCompiler) (entity: Fable.Entity) (case: Fable.UnionCase) =
    sprintf "\%s\%s" com.PhpNamespace (caseName com entity case)

/// find the case name from a Tag.
/// Used to instanciate DU cases as classes instances.
let caseNameOfTag ctx (entity: Fable.Entity) tag =
    caseName ctx entity entity.UnionCases.[tag]

let unscopedIdent name =
    {
        Name = name
        Namespace = None
        Class = None
    }

/// creates the class for a F# single case union.
let convertSingleCaseUnion (com: IPhpCompiler) (decl: Fable.ClassDecl) (info: Fable.Entity) =
    let case = info.UnionCases.[0]

    let t =
        {
            Namespace = Some com.PhpNamespace
            Name = fixName decl.Name
            Fields =
                [
                    for e in case.UnionCaseFields do
                        {
                            Name = e.Name
                            Type = convertType com e.FieldType
                        }
                ]
            Constructor =
                Some
                    {
                        Args = [ for e in case.UnionCaseFields -> e.Name ]
                        Body =
                            [
                                for e in case.UnionCaseFields ->
                                    PhpAssign(
                                        PhpField(PhpVar("this", None), StrField e.Name, None),
                                        PhpVar(e.Name, None)
                                    )
                            ]
                    }

            Methods =
                [
                    {
                        PhpFun.Name = "allCases"
                        PhpFun.Args = []
                        PhpFun.Matchings = []
                        PhpFun.Static = true
                        PhpFun.Body =
                            [
                                PhpStatement.PhpReturn(
                                    PhpNewArray(
                                        [
                                            PhpArrayNoIndex,
                                            PhpConst(PhpConstString(sprintf "\%s\%s" com.PhpNamespace case.Name))
                                        ]
                                    )
                                )
                            ]
                    }
                    {
                        PhpFun.Name = "get_FSharpCase"
                        PhpFun.Args = []
                        PhpFun.Matchings = []
                        PhpFun.Static = true
                        PhpFun.Body = [ PhpStatement.PhpReturn(PhpConst(PhpConstString(case.Name))) ]
                    }
                    yield! getTypeReflectionMethodsForFields com case.UnionCaseFields
                    {
                        PhpFun.Name = "get_Tag"
                        PhpFun.Args = []
                        PhpFun.Matchings = []
                        PhpFun.Static = false
                        PhpFun.Body = [ PhpStatement.PhpReturn(PhpConst(PhpConstNumber(0.))) ]
                    }
                    {
                        PhpFun.Name = "CompareTo"
                        PhpFun.Args = [ "other" ]
                        PhpFun.Matchings = []
                        PhpFun.Static = false
                        PhpFun.Body =
                            [
                                for e in case.UnionCaseFields do
                                    let cmp = PhpVar(com.MakeUniqueVar "cmp", None)

                                    match e.FieldType with
                                    | Fable.Type.Number _ ->
                                        PhpAssign(
                                            cmp,
                                            PhpTernary(
                                                PhpBinaryOp(
                                                    ">",
                                                    PhpField(
                                                        PhpVar("this", None),
                                                        Prop.Field
                                                            {
                                                                Name = e.Name
                                                                Type = convertType com e.FieldType
                                                            },
                                                        None
                                                    ),
                                                    PhpField(
                                                        PhpVar("other", None),
                                                        Prop.Field
                                                            {
                                                                Name = e.Name
                                                                Type = convertType com e.FieldType
                                                            },
                                                        None
                                                    )
                                                ),
                                                PhpConst(PhpConstNumber 1.),
                                                PhpTernary(
                                                    PhpBinaryOp(
                                                        "<",
                                                        PhpField(
                                                            PhpVar("this", None),
                                                            Prop.Field
                                                                {
                                                                    Name = e.Name
                                                                    Type = convertType com e.FieldType
                                                                },
                                                            None
                                                        ),
                                                        PhpField(
                                                            PhpVar("other", None),
                                                            Prop.Field
                                                                {
                                                                    Name = e.Name
                                                                    Type = convertType com e.FieldType
                                                                },
                                                            None
                                                        )
                                                    ),
                                                    PhpConst(PhpConstNumber -1.),
                                                    PhpConst(PhpConstNumber 0.)


                                                )
                                            )
                                        )
                                    | _ ->
                                        PhpAssign(
                                            cmp,
                                            PhpMethodCall(
                                                PhpField(
                                                    PhpVar("this", None),
                                                    Prop.Field
                                                        {
                                                            Name = e.Name
                                                            Type = convertType com e.FieldType
                                                        },
                                                    None
                                                ),
                                                PhpIdent(unscopedIdent "CompareTo"),
                                                [
                                                    PhpField(
                                                        PhpVar("other", None),
                                                        Prop.Field
                                                            {
                                                                Name = e.Name
                                                                Type = convertType com e.FieldType
                                                            },
                                                        None
                                                    )
                                                ]
                                            )

                                        )

                                    PhpIf(
                                        PhpBinaryOp("!=", cmp, PhpConst(PhpConstNumber 0.)),
                                        [ PhpStatement.PhpReturn cmp ],
                                        []
                                    )
                                PhpStatement.PhpReturn(PhpConst(PhpConstNumber 0.))
                            ]
                    }
                ]
            Abstract = false
            BaseType = None
            Interfaces = [ PhpUnion.fSharpUnion; Core.icomparable ]
            File = com.CurrentFile
            OriginalFullName = info.FullName
        }

    com.AddUse(Core.icomparable)
    com.AddUse(PhpUnion.fSharpUnion)
    t, []

let convertMultiCaseUnion (com: IPhpCompiler) (decl: Fable.ClassDecl) (info: Fable.Entity) =
    let baseType =
        {
            Namespace = Some com.PhpNamespace
            Name = fixName decl.Name
            Fields = []
            Constructor = None
            Methods =
                [
                    {
                        PhpFun.Name = "allCases"
                        PhpFun.Args = []
                        PhpFun.Matchings = []
                        PhpFun.Static = true
                        PhpFun.Body =
                            [
                                PhpStatement.PhpReturn(
                                    PhpNewArray(
                                        info.UnionCases
                                        |> Seq.map (fun case ->
                                            (PhpArrayNoIndex,
                                             PhpConst(PhpConstString(caseNameWithNamespace com info case)))
                                        )
                                        |> List.ofSeq
                                    )
                                )
                            ]
                    }
                ]
            Abstract = true
            BaseType = None
            Interfaces = [ PhpUnion.fSharpUnion ]
            File = com.CurrentFile
            OriginalFullName = info.FullName
        }

    com.AddUse(PhpUnion.fSharpUnion)

    let caseTypes =
        [
            for i, case in Seq.indexed info.UnionCases do
                let t =
                    let name = caseName com info case

                    {
                        Namespace = Some com.PhpNamespace
                        Name = name
                        Fields =
                            [
                                for e in case.UnionCaseFields do
                                    {
                                        Name = e.Name
                                        Type = convertType com e.FieldType
                                    }
                            ]
                        Constructor =
                            Some
                                {
                                    Args = [ for e in case.UnionCaseFields -> e.Name ]
                                    Body =
                                        [
                                            for e in case.UnionCaseFields ->
                                                PhpAssign(
                                                    PhpField(PhpVar("this", None), StrField e.Name, None),
                                                    PhpVar(e.Name, None)
                                                )
                                        ]
                                }

                        Methods =
                            [
                                {
                                    PhpFun.Name = "get_FSharpCase"
                                    PhpFun.Args = []
                                    PhpFun.Matchings = []
                                    PhpFun.Static = true
                                    PhpFun.Body = [ PhpStatement.PhpReturn(PhpConst(PhpConstString(case.Name))) ]
                                }
                                yield! getTypeReflectionMethodsForFields com case.UnionCaseFields
                                {
                                    PhpFun.Name = "get_Tag"
                                    PhpFun.Args = []
                                    PhpFun.Matchings = []
                                    PhpFun.Static = false
                                    PhpFun.Body = [ PhpStatement.PhpReturn(PhpConst(PhpConstNumber(float i))) ]
                                }
                                {
                                    PhpFun.Name = "CompareTo"
                                    PhpFun.Args = [ "other" ]
                                    PhpFun.Matchings = []
                                    PhpFun.Static = false
                                    PhpFun.Body =
                                        [
                                            let cmp = PhpVar(com.MakeUniqueVar "cmp", None)

                                            PhpAssign(
                                                cmp,
                                                PhpTernary(
                                                    PhpBinaryOp(
                                                        ">",
                                                        PhpMethodCall(
                                                            PhpVar("this", None),
                                                            PhpIdent(unscopedIdent "get_Tag"),
                                                            []
                                                        ),
                                                        PhpMethodCall(
                                                            PhpVar("other", None),
                                                            PhpIdent(unscopedIdent "get_Tag"),
                                                            []
                                                        )
                                                    ),
                                                    PhpConst(PhpConstNumber 1.),
                                                    PhpTernary(
                                                        PhpBinaryOp(
                                                            "<",
                                                            PhpMethodCall(
                                                                PhpVar("this", None),
                                                                PhpIdent(unscopedIdent "get_Tag"),
                                                                []
                                                            ),
                                                            PhpMethodCall(
                                                                PhpVar("other", None),
                                                                PhpIdent(unscopedIdent "get_Tag"),
                                                                []
                                                            )
                                                        ),
                                                        PhpConst(PhpConstNumber -1.),
                                                        PhpConst(PhpConstNumber 0.)
                                                    )
                                                )
                                            )

                                            if List.isEmpty case.UnionCaseFields then
                                                PhpStatement.PhpReturn(cmp)
                                            else
                                                PhpIf(
                                                    PhpBinaryOp("!=", cmp, PhpConst(PhpConstNumber 0.)),
                                                    [ PhpStatement.PhpReturn cmp ],
                                                    []
                                                )

                                                for e in case.UnionCaseFields do
                                                    let cmp = PhpVar(com.MakeUniqueVar "cmp", None)

                                                    match e.FieldType with
                                                    | Fable.Type.Number _ ->
                                                        PhpAssign(
                                                            cmp,
                                                            PhpTernary(
                                                                PhpBinaryOp(
                                                                    ">",
                                                                    PhpField(
                                                                        PhpVar("this", None),
                                                                        Prop.Field
                                                                            {
                                                                                Name = e.Name
                                                                                Type = convertType com e.FieldType
                                                                            },
                                                                        None
                                                                    ),
                                                                    PhpField(
                                                                        PhpVar("other", None),
                                                                        Prop.Field
                                                                            {
                                                                                Name = e.Name
                                                                                Type = convertType com e.FieldType
                                                                            },
                                                                        None
                                                                    )
                                                                ),
                                                                PhpConst(PhpConstNumber 1.),
                                                                PhpTernary(
                                                                    PhpBinaryOp(
                                                                        "<",
                                                                        PhpField(
                                                                            PhpVar("this", None),
                                                                            Prop.Field
                                                                                {
                                                                                    Name = e.Name
                                                                                    Type = convertType com e.FieldType
                                                                                },
                                                                            None
                                                                        ),
                                                                        PhpField(
                                                                            PhpVar("other", None),
                                                                            Prop.Field
                                                                                {
                                                                                    Name = e.Name
                                                                                    Type = convertType com e.FieldType
                                                                                },
                                                                            None
                                                                        )
                                                                    ),
                                                                    PhpConst(PhpConstNumber -1.),
                                                                    PhpConst(PhpConstNumber 0.)


                                                                )
                                                            )
                                                        )
                                                    | _ ->
                                                        PhpAssign(
                                                            cmp,
                                                            PhpMethodCall(
                                                                PhpField(
                                                                    PhpVar("this", None),
                                                                    Prop.Field
                                                                        {
                                                                            Name = e.Name
                                                                            Type = convertType com e.FieldType
                                                                        },
                                                                    None
                                                                ),
                                                                PhpIdent(unscopedIdent "CompareTo"),
                                                                [
                                                                    PhpField(
                                                                        PhpVar("other", None),
                                                                        Prop.Field
                                                                            {
                                                                                Name = e.Name
                                                                                Type = convertType com e.FieldType
                                                                            },
                                                                        None
                                                                    )
                                                                ]
                                                            )

                                                        )

                                                    PhpIf(
                                                        PhpBinaryOp("!=", cmp, PhpConst(PhpConstNumber 0.)),
                                                        [ PhpStatement.PhpReturn cmp ],
                                                        []
                                                    )

                                                PhpStatement.PhpReturn(PhpConst(PhpConstNumber 0.))
                                        ]
                                }

                            ]
                        Abstract = false
                        BaseType = Some baseType
                        Interfaces = [ Core.icomparable ]
                        File = com.CurrentFile
                        OriginalFullName = info.FullName + "_" + name
                    }

                com.AddUse(Core.icomparable)
                com.AddType(None, t)
                t
        ]

    baseType, caseTypes

/// creates Php classes for an F# union type
let convertUnion (com: IPhpCompiler) (decl: Fable.ClassDecl) (info: Fable.Entity) =
    if info.UnionCases.Length = 1 then
        convertSingleCaseUnion com decl info
    else
        convertMultiCaseUnion com decl info

/// creates Php class for a F# record
let convertRecord (com: IPhpCompiler) (decl: Fable.ClassDecl) (info: Fable.Entity) =
    let t =
        {
            Namespace = Some com.PhpNamespace
            Name = fixName decl.Name
            Fields =
                [
                    for e in info.FSharpFields do
                        {
                            Name = e.Name
                            Type = convertType com e.FieldType
                        }
                ]
            Constructor =
                Some
                    {
                        Args = [ for e in info.FSharpFields -> e.Name ]
                        Body =
                            [
                                for e in info.FSharpFields ->
                                    PhpAssign(
                                        PhpField(PhpVar("this", None), StrField e.Name, None),
                                        PhpVar(e.Name, None)
                                    )
                            ]
                    }
            Methods =
                [
                    yield! getTypeReflectionMethodsForFields com info.FSharpFields
                    {
                        PhpFun.Name = "CompareTo"
                        PhpFun.Args = [ "other" ]
                        PhpFun.Matchings = []
                        PhpFun.Static = false
                        PhpFun.Body =
                            [
                                for e in info.FSharpFields do
                                    let cmp = PhpVar(com.MakeUniqueVar "cmp", None)

                                    match e.FieldType with
                                    | Fable.Number _
                                    | Fable.String ->
                                        PhpAssign(
                                            cmp,
                                            PhpTernary(
                                                PhpBinaryOp(
                                                    ">",
                                                    PhpField(
                                                        PhpVar("this", None),
                                                        Prop.Field
                                                            {
                                                                Name = e.Name
                                                                Type = convertType com e.FieldType
                                                            },
                                                        None
                                                    ),
                                                    PhpField(
                                                        PhpVar("other", None),
                                                        Prop.Field
                                                            {
                                                                Name = e.Name
                                                                Type = convertType com e.FieldType
                                                            },
                                                        None
                                                    )
                                                ),
                                                PhpConst(PhpConstNumber 1.),
                                                PhpTernary(
                                                    PhpBinaryOp(
                                                        "<",
                                                        PhpField(
                                                            PhpVar("this", None),
                                                            Prop.Field
                                                                {
                                                                    Name = e.Name
                                                                    Type = convertType com e.FieldType
                                                                },
                                                            None
                                                        ),
                                                        PhpField(
                                                            PhpVar("other", None),
                                                            Prop.Field
                                                                {
                                                                    Name = e.Name
                                                                    Type = convertType com e.FieldType
                                                                },
                                                            None
                                                        )
                                                    ),
                                                    PhpConst(PhpConstNumber -1.),
                                                    PhpConst(PhpConstNumber 0.)


                                                )
                                            )
                                        )
                                    | _ ->
                                        PhpAssign(
                                            cmp,
                                            PhpMethodCall(
                                                PhpField(
                                                    PhpVar("this", None),
                                                    Prop.Field
                                                        {
                                                            Name = e.Name
                                                            Type = convertType com e.FieldType
                                                        },
                                                    None
                                                ),
                                                PhpIdent(unscopedIdent "CompareTo"),
                                                [
                                                    PhpField(
                                                        PhpVar("other", None),
                                                        Prop.Field
                                                            {
                                                                Name = e.Name
                                                                Type = convertType com e.FieldType
                                                            },
                                                        None
                                                    )
                                                ]
                                            )

                                        )

                                    PhpIf(
                                        PhpBinaryOp("!=", cmp, PhpConst(PhpConstNumber 0.)),
                                        [ PhpStatement.PhpReturn cmp ],
                                        []
                                    )
                                PhpStatement.PhpReturn(PhpConst(PhpConstNumber 0.))
                            ]
                    }

                ]
            Abstract = false
            BaseType = None
            Interfaces = [ Core.icomparable ]
            File = com.CurrentFile
            OriginalFullName = info.FullName
        }

    com.AddUse(Core.icomparable)
    t, []


/// Return strategy for expression compiled as statements
/// F# is expression based, but some constructs have to be transpiled as
/// statements in other languages. This types indicates how the result
/// should be passed to the resto of the code.
type ReturnStrategy =
    /// The statement should return the value
    | Return
    /// The statement should define a new variable and assign it
    | Let of string
    /// No return value
    | Do
    /// used in decision tree when multiple cases result in the same code
    | Target of string

let nsreplacement (ns: string) =
    match ns.Replace(".", @"\") with
    | "ListModule" -> "FSharpList"
    | "ArrayModule" -> "FSharpArray"
    | "SeqModule" -> "Seq"
    | "SeqModule2" -> "Seq2"
    | ns -> ns


let phpObj = unscopedIdent "object"
let phpString = unscopedIdent "string"
let phpInt = unscopedIdent "int"
let phpFloat = unscopedIdent "float"
let phpBool = unscopedIdent "bool"
let phpChar = unscopedIdent "char"
let phpVoid = unscopedIdent "void"

/// convert fable type of Php type name for type comparison (instanceof)
let rec convertTypeRef (com: IPhpCompiler) (t: Fable.Type) =
    match t with
    | Fable.String -> ExType phpString
    | Fable.Number((Int32 | Int16 | Int8 | UInt16 | UInt32 | UInt8 | Int64 | UInt64), _) -> ExType phpInt
    | Fable.Number((Float32 | Float64), _) -> ExType phpFloat
    | Fable.Number((BigInt | Decimal | NativeInt | UNativeInt | Int128 | UInt128 | Float16), _) -> ExType phpObj
    | Fable.Boolean -> ExType phpBool
    | Fable.Char -> ExType phpChar
    | Fable.AnonymousRecordType _ -> ExType phpObj
    | Fable.Any -> ExType phpObj
    | Fable.DelegateType _ -> ExType phpObj
    | Fable.LambdaType _ -> ExType phpObj
    | Fable.GenericParam _ -> ExType phpObj
    | Fable.Array(t, _) -> ArrayRef(convertTypeRef com t)
    | Fable.List _ ->
        ExType
            {
                Name = "FSharpList"
                Namespace = Some "FSharpList"
                Class = None
            }
    | Fable.Nullable(t, _)
    | Fable.Option(t, _) ->
        ExType
            {
                Name = "object"
                Namespace = None
                Class = None
            }
    | Fable.DeclaredType(ref, _) ->
        //        let ent = com.GetEntity(ref)
        match com.TryFindType(ref) with
        | Ok phpType -> InType phpType
        | Error ent -> ExType(getPhpTypeForEntity com ent)
    | Fable.Measure _ -> failwithf "Measure not supported"
    | Fable.MetaType -> failwithf "MetaType not supported"
    | Fable.Regex -> failwithf "Regex not supported"
    | Fable.Tuple _ -> ExType phpObj
    | Fable.Unit -> ExType phpVoid

and getPhpTypeForEntity (com: IPhpCompiler) (entity: Fable.Entity) =
    match entity.Ref.SourcePath with
    | Some path ->
        match entity with
        | :? Fable.Transforms.FSharp2Fable.FsEnt as fs ->
            let ns = com.GetRootModule(path) |> fst |> nsreplacement |> Some

            {
                Name = fixName fs.FSharpEntity.CompiledName
                Namespace = ns
                Class = None
            }

        | _ ->
            let rootModule = com.GetRootModule(path) |> fst

            {
                Name = fixName entity.DisplayName
                Namespace = Some(nsreplacement rootModule)
                Class = None
            }
    | None ->
        {
            Name = fixName entity.DisplayName
            Namespace = Some ""
            Class = None
        }


let withNamespace ns name =
    {
        Name = name
        Namespace = ns
        Class = None
    }

let libCall (com: IPhpCompiler) file ns memberName args =


    let file = "fable-library/" + file + ".php"
    com.AddRequire(file)
    let moduleName = ns |> nsreplacement

    PhpFunctionCall(PhpIdent(withNamespace (Some moduleName) memberName), args)


/// convert a test (expression that returns a boolean) to a Php construct
let convertTest (com: IPhpCompiler) test phpExpr =
    let phpIsNull phpExpr =
        PhpFunctionCall(PhpIdent(unscopedIdent "is_null"), [ phpExpr ])

    match test with
    | Fable.TestKind.UnionCaseTest(tag) ->
        // union test case is implemented with a ->get_Tag() test
        PhpBinaryOp(
            "==",
            PhpMethodCall(phpExpr, PhpIdent(unscopedIdent "get_Tag"), []),
            PhpConst(PhpConstNumber(float tag))
        )
    | Fable.TestKind.ListTest(isCons) ->
        // list test is implemented with a instanceof Cons / instanceof Nil test
        if isCons then
            //com.AddUse(PhpList.cons)
            //PhpInstanceOf(phpExpr, InType PhpList.cons)
            PhpUnaryOp("!", libCall com "List" "FSharpList" "isEmpty" [ phpExpr ])
        else
            libCall com "List" "FSharpList" "isEmpty" [ phpExpr ]
    //com.AddUse(PhpList.nil)
    //PhpInstanceOf(phpExpr, InType PhpList.nil)
    | Fable.OptionTest(isSome) ->
        // option is implementd using null, test is implemented with a is_null call
        if isSome then
            PhpUnaryOp("!", phpIsNull phpExpr)
        else
            phpIsNull phpExpr
    | Fable.TypeTest(t) ->
        // use instanceof
        let phpType = convertTypeRef com t

        match phpType with
        | PhpTypeRef.ArrayRef _ -> PhpFunctionCall(PhpIdent(unscopedIdent "is_array"), [ phpExpr ])

        | _ -> PhpInstanceOf(phpExpr, phpType)


let getExprType =
    function
    | PhpVar(_, t) -> t
    | PhpField(_, _, t) -> t
    | _ -> None

let rec tryFindField fieldName (phpType: PhpType) =
    match phpType.Fields |> List.tryFind (fun f -> f.Name = fieldName) with
    | Some f -> Some f
    | None ->
        match phpType.BaseType with
        | Some t -> tryFindField fieldName t
        | None -> None

let rec tryFindMethod methodName (phpType: PhpType) =
    match phpType.Methods |> List.tryFind (fun f -> f.Name = methodName) with
    | Some f -> Some f
    | None ->
        match phpType.BaseType with
        | Some t -> tryFindMethod methodName t
        | None -> None

/// convert a Fable expression to a Php expression
let rec convertExpr (com: IPhpCompiler) (expr: Fable.Expr) =
    match expr with
    | Fable.Extended _ -> failwith "TODO: Extended instructions"

    | Fable.Unresolved _ -> failwith "Unexpected unresolved expression"

    | Fable.Value(value, range) ->
        // this is a value (number / record instanciation ...)
        convertValue com value range

    | Fable.Operation(Fable.Binary(op, left, right), _, t, _) ->
        // the result of a binary operation
        let opstr =
            match op with
            | BinaryOperator.BinaryMultiply -> "*"
            | BinaryOperator.BinaryPlus ->
                match t with
                | Fable.Type.String -> "." // Php string concatenation is done with '.'
                | _ -> "+"
            | BinaryOperator.BinaryMinus -> "-"
            | BinaryOperator.BinaryLess -> "<"
            | BinaryOperator.BinaryGreater -> ">"
            | BinaryOperator.BinaryLessOrEqual -> "<="
            | BinaryOperator.BinaryGreaterOrEqual -> ">="
            | BinaryOperator.BinaryAndBitwise -> "&"
            | BinaryOperator.BinaryOrBitwise -> "|"
            | BinaryOperator.BinaryXorBitwise -> "^"
            | BinaryOperator.BinaryEqual -> "==="
            | BinaryOperator.BinaryUnequal -> "!=="
            | BinaryOperator.BinaryModulus -> "%"
            | BinaryOperator.BinaryDivide -> "/"
            | BinaryOperator.BinaryExponent -> "**"
            | BinaryOperator.BinaryShiftLeft -> "<<"
            | BinaryOperator.BinaryShiftRightSignPropagating -> ">>"
            | BinaryOperator.BinaryShiftRightZeroFill -> ">>>"

        PhpBinaryOp(opstr, convertExpr com left, convertExpr com right)
    | Fable.Operation(Fable.Unary(op, expr), _, _, _) ->
        let opStr =
            match op with
            | UnaryOperator.UnaryNot -> "!"
            | UnaryOperator.UnaryMinus -> "-"
            | UnaryOperator.UnaryPlus -> "+"
            | UnaryOperator.UnaryNotBitwise -> "~~~"
            | UnaryOperator.UnaryAddressOf -> failwith "UnaryAddressOf not supported"

        PhpUnaryOp(opStr, convertExpr com expr)
    | Fable.Operation(Fable.Logical(op, left, right), _, _, _) ->
        // this is a binary logical operation
        let opstr =
            match op with
            | LogicalOperator.LogicalAnd -> "&&"
            | LogicalOperator.LogicalOr -> "||"

        PhpBinaryOp(opstr, convertExpr com left, convertExpr com right)


    | Fable.Expr.Call(callee,
                      ({
                           ThisArg = None
                           Args = args
                       } as i),
                      ty,
                      _) ->
        // static function call
        match callee with
        | Fable.Import({ Selector = "op_UnaryNegation_Int32" }, _, _) -> PhpUnaryOp("-", convertExpr com args.[0])
        | Fable.Get(this, Fable.FieldGet i, _, _) -> PhpField(convertExpr com this, StrField(fixName i.Name), None)
        | Fable.Get((Fable.Get(_, _, ty, _) as this), Fable.ExprGet(Fable.Value(Fable.StringConstant m, None)), _, _) when
            match ty with
            | Fable.Array _ -> true
            | _ -> false
            ->
            // Convert calls on Array as FSharpArray since Array is reserved by Php
            libCall com "Array" "FSharpArray" m (convertArgs com (args @ [ this ]))
        | Fable.Get(Fable.IdentExpr { Name = "Math" }, Fable.ExprGet(Fable.Value(Fable.StringConstant m, None)), _, _) ->
            // convert calls to math to direct function call
            // a mapping could be done here to avoid calling functions that dont exist.

            PhpFunctionCall(PhpIdent(unscopedIdent m), convertArgs com args)
        | Fable.Get(target, Fable.ExprGet(Fable.Value(Fable.StringConstant m, None)), _, _) ->
            // convert calls on Array object by string key.
            // in Php $a['Hello'] is equivalent to $a->Hello, we choose this representation.

            let meth = m.Substring(m.LastIndexOf(".") + 1)

            PhpMethodCall(convertExpr com target, PhpIdent(unscopedIdent meth), convertArgs com (args))

        | Fable.IdentExpr(id) when id.DisplayName = "( .ctor )" ->

            let classId =
                match ty with
                | Fable.DeclaredType(entref, _) ->
                    let ent = com.GetEntity(entref)
                    getPhpTypeForEntity com ent
                | _ -> failwith "Not implemented"
            // this is a ctor
            PhpNew(ExType classId, convertArgs com args)

        | _ ->
            // simply call the function
            let phpCallee = convertExpr com callee

            match phpCallee with
            | PhpVar(name, _) ->
                // is a call to a function by value,
                // add a byref use to make recursive function work
                // in Php recursive functions have to declare their own use:
                // $loop = function ($arg) use (&$loop) { ... }
                // it has to be byref to enable outer scope variable mutations
                com.UseVarByRef(name)
            | _ -> ()

            PhpFunctionCall(phpCallee, convertArgs com args)

    | Fable.Expr.Call(Fable.Import({
                                       Selector = name
                                       Path = "."
                                   },
                                   _,
                                   _),
                      {
                          ThisArg = Some this
                          Args = args
                      },
                      ty,
                      _) ->
        // call to a member in the same namespace
        let methodName =
            match this.Type with
            | Fable.DeclaredType(entref, _) ->
                let ent = com.GetEntityName(com.GetEntity(entref))
                name.Substring(ent.Length + 2)
            | _ -> name

        PhpMethodCall(convertExpr com this, PhpIdent(unscopedIdent methodName), convertArgs com args)
    | Fable.Expr.Call(callee,
                      {
                          ThisArg = Some this
                          Args = args
                      },
                      ty,
                      _) ->
        //this is a method call

        let phpCallee = convertExpr com callee

        PhpMethodCall(convertExpr com this, phpCallee, convertArgs com args)

    | Fable.CurriedApply(expr, args, _, _) ->
        // converted to a simple Php function call
        PhpFunctionCall(convertExpr com expr, [ for arg in args -> convertExpr com arg ])

    | Fable.Emit(info, _, _) ->
        // convert to Php macro preparing replacements
        // see in the Printer to see how it's handled
        PhpMacro(info.Macro, [ for arg in info.CallInfo.Args -> convertExpr com arg ])
    | Fable.Get(expr, kind, tex, _) ->
        // this is a value access
        let phpExpr = convertExpr com expr

        match kind with
        | Fable.UnionField i ->
            // had to add the field info (and not only the index)
            // because we implement Cases as classes (contrary to JS where cases are arrays)
            let ent = com.GetEntity(i.Entity)

            List.tryItem i.CaseIndex ent.UnionCases
            |> Option.bind (fun unionCase -> unionCase.UnionCaseFields |> List.tryItem i.FieldIndex)
            |> Option.map (fun field -> PhpField(phpExpr, StrField field.Name, None))
            |> Option.defaultWith (fun _ -> failwith "Cannot find union field name")
        | Fable.OptionValue ->
            // option is simply erased
            phpExpr
        | Fable.FieldGet i ->
            let name = i.Name

            match getExprType phpExpr with
            | Some phpType ->
                match tryFindField name phpType with
                | Some field -> PhpField(phpExpr, Field field, com.TryFindType(field.Type))
                | None ->
                    match tryFindMethod name phpType with
                    | Some prop ->
                        match expr with
                        | Fable.IdentExpr id ->
                            // this is a static call
                            if prop.Static then
                                PhpFunctionCall(
                                    PhpIdent
                                        {
                                            Namespace = None
                                            Class = Some(fixName id.Name)
                                            Name = name
                                        },
                                    []
                                )
                            else
                                PhpMethodCall(phpExpr, PhpIdent(unscopedIdent name), [])

                        | _ -> PhpMethodCall(phpExpr, PhpIdent(unscopedIdent name), [])
                    | None -> failwith "Field of property not found"
            | None -> PhpField(phpExpr, StrField name, None)

        | Fable.GetKind.TupleIndex(id) ->
            // this is a tuple value access. Tuples are transpiled as arrays
            PhpArrayAccess(phpExpr, PhpConst(PhpConstNumber(float id)))
        | Fable.ExprGet expr' ->
            // the access key is an expression
            let prop = convertExpr com expr'

            match prop with
            | PhpConst(PhpConstString "length") ->
                // length property is converted to a static count call
                PhpFunctionCall(PhpIdent(unscopedIdent "count"), [ phpExpr ])
            | _ -> PhpArrayAccess(phpExpr, prop)

        | Fable.ListHead ->
            // This access cons->Value field in a list node
            libCall com "List" "FSharpList" "head" [ phpExpr ]
        | Fable.ListTail ->
            // this acess cons->Next field in a list node
            libCall com "List" "FSharpList" "tail" [ phpExpr ]
        | Fable.UnionTag ->
            // this calls get_Tag on an union
            PhpMethodCall(phpExpr, PhpIdent(unscopedIdent ("get_Tag")), [])

    | Fable.IdentExpr(id) ->
        // this is an identifier

        // try to find the type
        let phpType =
            match id.Type with
            | Fable.Type.DeclaredType(e, _) -> com.TryFindType e.FullName
            | _ -> None

        let name =
            // fable doesn't keep track of 'this'
            // when entering a method, we put the identifier for 'this' in the context
            // and check it here for replacement
            if com.IsThisArgument(id) then
                "this"
            else
                // this is a standard identifier access
                let name = fixName id.Name
                // indicates the value is used (for capture)
                com.UseVar(name)
                name

        match com.IsImport name with
        | Some true -> PhpGlobal(name)
        | Some false -> PhpIdent(unscopedIdent name)
        | None -> PhpVar(name, phpType)
    | Fable.Import(info, t, _) ->
        // this is an import

        // helper to fix reserverd php names
        let fixNsName =
            function
            | "List" -> "FSharpList"
            | "Array" -> "FSharpArray"
            | n -> n


        //match t with
        //| Fable.Type.DeclaredType(fs,_) ->
        //    let ent = com.GetEntity(fs)
        //    let file = com.GetImplementationFile(info.Path)
        //    let decl = file.Declarations |> List.tryFind (function Fable.  decl when decl.Name = info.Selector -> true | _ -> false )
        //    printfn "%A" fs
        //| _ -> printfn "%A" t

        match fixNsName (Path.GetFileNameWithoutExtension(info.Path)) with
        | "" ->
            match com.IsImport info.Selector with
            | Some true ->
                let name = fixName info.Selector
                PhpGlobal(name)
            | _ -> PhpIdent(unscopedIdent (fixName info.Selector))

        | ns ->
            match com.IsImport info.Selector with
            | Some true ->
                let name = fixName info.Selector
                PhpGlobal(name)
            | _ ->
                com.AddRequire(info.Path)

                let sepPos = info.Selector.IndexOf("__", StringComparison.Ordinal)

                if sepPos >= 0 then
                    PhpIdent(unscopedIdent (fixName (info.Selector.Substring(sepPos + 2))))
                else
                    PhpIdent
                        {
                            Namespace = Some ns
                            Class = None
                            Name = fixName info.Selector
                        }

    | Fable.DecisionTree(expr, targets) ->
        // converts a decision tree.
        // it defines some targets that will be referenced inside expr
        // by DecistionTreeSuccess(index, ... ) nodes
        // we add these targets to local compiler context (after
        // saving targets from outer scope)
        let outerTargets = com.DecisionTargets
        // and set the local targets
        com.SetDecisionTargets(targets)
        let phpExpr = convertExpr com expr
        com.SetDecisionTargets(outerTargets)
        phpExpr

    | Fable.IfThenElse(guard, thenExpr, elseExpr, _) ->
        // when converting as an expression, a IfThenElse F# expre
        // is transpiled to a Php Ternary 'guard ? thenExpr : elseExpr'
        PhpTernary(convertExpr com guard, convertExpr com thenExpr, convertExpr com elseExpr)


    | Fable.Test(expr, test, _) ->
        // this is a test expression (see convertTest)
        let phpExpr = convertExpr com expr
        convertTest com test phpExpr


    | Fable.DecisionTreeSuccess(index, [], _) ->
        // the index indicates which condition target to jump too
        // here there is no variable bindings, so the expression
        // can just be transpiled in place
        let _, target = com.DecisionTargets.[index]
        convertExpr com target
    | Fable.DecisionTreeSuccess(index, boundValues, _) ->
        // in this version, there are variable bindings that make it multi statement
        // To circumvent the fact that Php has no comma operator, we embed the whole
        // thing in an anonymous function that is instantly executed:
        //  | x,y -> x + y
        // is conceptually transpiled to
        // (function () use ($match) {
        //      $x = $match[0];
        //      $y = $match[1];
        //      return $x + $y }) ()

        // find the target and associated bindings
        let bindings, target = com.DecisionTargets.[index]

        // convert bound values expressions
        let args = List.map (convertExpr com) boundValues

        // prepare the scope for the anonymous function
        com.NewScope()
        // declare bound value usage in scope
        for id in bindings do
            com.AddLocalVar(fixName id.Name, id.IsMutable)
        // convert body
        let body = convertExprToStatement com target Return

        let uses = com.RestoreScope()
        // create anonymous function and call it with args
        PhpFunctionCall(PhpAnonymousFunc([ for id in bindings -> fixName id.Name ], uses, body), args)


    | Fable.ObjectExpr(members, t, baseCall) ->
        // this is an object ceation expr and is compiled as
        // an array with string keys.
        PhpNewArray
            [
                for m in members do
                    PhpArrayString m.Name, convertFunction com m.Body m.Args
            ]
    | Fable.Expr.Lambda(arg, body, _) ->
        // lambda is transpiled as a function
        convertFunction com body [ arg ]
    | Fable.Expr.Delegate(args, body, _, _) ->
        // delegates are also tanspiled as functions
        convertFunction com body args


    | Fable.Let(id, expr, body) ->
        // this defines a name id for the expression expr that is used in body
        // let x = expr in body
        // to transpile it to an expression, we consider it as:
        // (fun x -> body) expr
        // and transpile it as:
        // (function ($x) { ... body ... }) ($expr)

        // convert expr in outer scope
        let phpExpr = convertExpr com expr
        // open new scope
        com.NewScope()
        // add $x to local var (in case of inner capture)
        com.AddLocalVar(fixName id.Name, id.IsMutable)
        // convert body in inner scope
        let phpBody = convertExprToStatement com body Return
        // close scope and get captures variables
        let uses = com.RestoreScope()

        PhpFunctionCall(PhpAnonymousFunc([ id.Name ], uses, phpBody), [ phpExpr ])

    | Fable.Expr.TypeCast(expr, _) ->
        // for now we ignore casts... should probably be improved
        convertExpr com expr
    | Fable.Expr.Sequential([ Fable.Value(Fable.UnitConstant, _); body ]) ->
        // a sequènce of a unit and a body... just get rid of the Unit.
        convertExpr com body
    | Fable.Expr.Sequential(_) ->
        // this is a multi part/sequential expression
        // as before, this doesn't exist in Php, so we convert it to multiple
        // statements inside an anonymous function
        // (doSomething();y)  is valid in F# an have a value 'y'
        // it is converted to
        // (function () { doSomething(); retrun y; })()

        // open scope for the function
        com.NewScope()
        // convert body as statements with 'Return' strategy
        let body = convertExprToStatement com expr Return
        // close the scope and get captured vars
        let uses = com.RestoreScope()
        PhpFunctionCall(PhpAnonymousFunc([], uses, body), [])
    | Fable.LetRec(bindings, body) -> failwith "LetRec is not implemented"
    | Fable.ForLoop _
    | Fable.WhileLoop _
    | Fable.Set _
    | Fable.TryCatch _ ->
        // these constructs should always be embeded in a function
        // and converted using the convertExprToStatement
        failwith "Should not appear in expression"

/// convert a list of arguments
and convertArgs com (args: Fable.Expr list) =
    [
        for arg in args do
            match arg with
            | Fable.IdentExpr({
                                  Name = "Array"
                                  IsCompilerGenerated = true
                              }) -> ()
            | _ ->
                match arg.Type with
                | Fable.Unit -> PhpConst(PhpConstNull) // remove Unit passed by value
                | _ -> convertExpr com arg
    ]


and convertFunction (com: IPhpCompiler) body (args: Fable.Ident list) =
    com.NewScope()

    let args =
        [
            for arg in args do
                let argName = fixName arg.Name
                com.AddLocalVar(argName, arg.IsMutable)
                argName
        ]

    let phpBody = convertExprToStatement com body Return

    let uses = com.RestoreScope()
    PhpAnonymousFunc(args, uses, phpBody)

and convertValue (com: IPhpCompiler) (value: Fable.ValueKind) range =
    match value with
    | Fable.NewUnion(args, tag, ent, _) ->
        let ent = com.GetEntity(ent)

        let t =
            let name = caseNameOfTag com ent tag

            match com.TryFindType name with
            | Some t ->
                com.AddRequire(t)
                InType t
            | None ->
                let rootModule =
                    match ent.Ref.SourcePath with
                    | Some p ->
                        com.AddRequire(p)
                        Some(com.GetRootModule(p) |> fst)
                    | None -> None

                let t = withNamespace rootModule name
                ExType t


        PhpNew(
            t,
            [
                for arg in args do
                    convertExpr com arg
            ]
        )
    | Fable.NewTuple(args, _) ->

        PhpNewArray(
            [
                for arg in args do
                    (PhpArrayNoIndex, convertExpr com arg)
            ]
        )
    | Fable.NewRecord(args, e, _) ->
        let t =
            match com.TryFindType(e) with
            | Ok t ->
                com.AddRequire(t)
                InType t
            | Error ent ->
                let t = getPhpTypeForEntity com ent

                match e.SourcePath with
                | Some p -> com.AddRequire p
                | None -> ()

                ExType t

        PhpNew(
            t,
            [
                for arg in args do
                    convertExpr com arg
            ]
        )


    | Fable.NumberConstant(x, _) ->
        match x with
        | Fable.NumberValue.Int8 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.UInt8 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.Int16 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.UInt16 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.Int32 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.UInt32 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.Float32 x -> PhpConst(PhpConstNumber(float x))
        | Fable.NumberValue.Float64 x -> PhpConst(PhpConstNumber(x))
        | _ ->
            addError com [] range $"Numeric literal is not supported: %A{x}"

            PhpConst(PhpConstNull)
    | Fable.StringTemplate _ ->
        addError com [] range $"String templates are not supported"
        PhpConst(PhpConstNull)
    | Fable.StringConstant(s) -> PhpConst(PhpConstString s)
    | Fable.BoolConstant(b) -> PhpConst(PhpConstBool b)
    | Fable.UnitConstant -> PhpConst(PhpConstNull)
    | Fable.CharConstant(c) -> PhpConst(PhpConstString(string<char> c))
    | Fable.Null _ -> PhpConst(PhpConstNull)
    | Fable.NewList(Some(head, tail), _) ->
        libCall com "List" "FSharpList" "cons" [ convertExpr com head; convertExpr com tail ]
    | Fable.NewList(None, _) -> libCall com "List" "FSharpList" "_empty" []
    | Fable.NewArray(kind, _, _) ->
        match kind with
        | Fable.ArrayValues values -> PhpNewArray([ for v in values -> (PhpArrayNoIndex, convertExpr com v) ])
        | _ -> PhpNewArray([]) // TODO

    | Fable.NewOption(opt, _, _) ->
        match opt with
        | Some expr -> convertExpr com expr
        | None -> PhpConst(PhpConstNull)
    | Fable.NewAnonymousRecord(values, fields, _genArgs, _isStruct) ->
        PhpNewArray[for i in 0 .. values.Length - 1 do
                        PhpArrayString fields.[i], convertExpr com values.[i]]

    | Fable.BaseValue(ident, _) ->
        match ident with
        | None -> PhpParent
        | Some ident -> convertExpr com (Fable.IdentExpr ident)
    | Fable.RegexConstant(source, flags) ->
        let modifiers =
            flags
            |> List.map (
                function
                | RegexUnicode -> ""
                | RegexIgnoreCase -> "i"
                | RegexMultiline -> "m"
                | RegexSingleline -> "s"
                | RegexGlobal ->
                    addWarning com [] range "Regex global flag is not supported in Php"

                    ""
                | RegexSticky ->
                    addWarning com [] range "Regex sticky flag is not supported in Php"

                    ""
            )
            |> String.concat ""

        PhpConst(PhpConstString("/" + source + "/" + modifiers))
    | Fable.ThisValue _ -> PhpVar("this", None)
    | Fable.TypeInfo _ -> failwith "Not implemented"

and canBeCompiledAsSwitch evalExpr tree =
    match tree with
    | Fable.IfThenElse(Fable.Test(caseExpr, Fable.UnionCaseTest(tag), _),
                       Fable.DecisionTreeSuccess(index, _, _),
                       elseExpr,
                       _) when caseExpr = evalExpr -> canBeCompiledAsSwitch evalExpr elseExpr
    | Fable.DecisionTreeSuccess(index, _, _) -> true
    | _ -> false

and findCasesNames evalExpr tree =

    [
        match tree with
        | Fable.IfThenElse(Fable.Test(caseExpr, Fable.UnionCaseTest(tag), _),
                           Fable.DecisionTreeSuccess(index, bindings, _),
                           elseExpr,
                           _) when caseExpr = evalExpr ->
            Some tag, bindings, index
            yield! findCasesNames evalExpr elseExpr
        | Fable.DecisionTreeSuccess(index, bindings, _) -> None, bindings, index
        | _ -> ()
    ]

and hasGroupedCases indices tree =
    match tree with
    | Fable.IfThenElse(Fable.Test(_), Fable.DecisionTreeSuccess(index, _, _), elseExpr, _) ->
        if Set.contains index indices then
            true
        else
            hasGroupedCases (Set.add index indices) elseExpr
    | Fable.DecisionTreeSuccess(index, _, _) ->
        if Set.contains index indices then
            true
        else
            false
    | Fable.IfThenElse(Fable.Test(_), _, _, _) -> false
    | _ -> failwithf "Invalid Condition AST"

and getCases cases tree =
    match tree with
    | Fable.IfThenElse(Fable.Test(_), Fable.DecisionTreeSuccess(index, boundValues, _), elseExpr, _) ->
        getCases (Map.add index boundValues cases) elseExpr
    | Fable.DecisionTreeSuccess(index, boundValues, _) -> Map.add index boundValues cases
    | Fable.IfThenElse(Fable.Test(_), _, _, _) -> cases
    | _ -> failwithf "Invalid Condition AST"


and convertMatching (com: IPhpCompiler) input guard thenExpr elseExpr expr returnStrategy =
    if (canBeCompiledAsSwitch expr input) then
        let tags = findCasesNames expr input
        let inputExpr = convertExpr com expr

        [
            PhpSwitch(
                PhpMethodCall(inputExpr, PhpIdent(unscopedIdent "get_Tag"), []),
                [
                    for tag, bindings, i in tags ->
                        let idents, target = com.DecisionTargets.[i]

                        let phpCase =
                            match tag with
                            | Some t -> IntCase t
                            | None -> DefaultCase


                        phpCase,
                        [
                            for ident, binding in List.zip idents bindings do
                                com.AddLocalVar(fixName ident.Name, ident.IsMutable)

                                PhpAssign(PhpVar(fixName ident.Name, None), convertExpr com binding)
                            match returnStrategy with
                            | Target t ->
                                com.AddLocalVar(fixName t, false)

                                PhpAssign(PhpVar(fixName t, None), PhpConst(PhpConstNumber(float i)))

                                PhpBreak None
                            | Return -> yield! convertExprToStatement com target returnStrategy
                            | _ ->
                                yield! convertExprToStatement com target returnStrategy

                                PhpBreak None
                        ]
                ]
            )

        ]
    else
        [
            PhpIf(
                convertExpr com guard,
                convertExprToStatement com thenExpr returnStrategy,
                convertExprToStatement com elseExpr returnStrategy
            )
        ]

and convertExprToStatement (com: IPhpCompiler) expr returnStrategy =
    match expr with
    | Fable.DecisionTree(input, targets) ->

        let upperTargets = com.DecisionTargets
        com.SetDecisionTargets(targets)
        let phpExpr = convertExprToStatement com input returnStrategy
        com.SetDecisionTargets(upperTargets)
        phpExpr
    | Fable.IfThenElse(Fable.Test(expr, Fable.TestKind.UnionCaseTest(tag), _) as guard, thenExpr, elseExpr, _) as input ->
        let groupCases = hasGroupedCases Set.empty input

        if groupCases then
            let targetName = com.MakeUniqueVar("target")
            let targetVar = PhpVar(targetName, None)

            let switch1 =
                convertMatching com input guard thenExpr elseExpr expr (Target targetName)

            let cases = getCases Map.empty input

            let switch2 =
                PhpSwitch(
                    targetVar,
                    [
                        for i, (idents, expr) in List.indexed com.DecisionTargets do
                            IntCase i,
                            [
                                match Map.tryFind i cases with
                                | Some case ->
                                    // Assigns have already been made in switch 1
                                    yield! convertExprToStatement com expr returnStrategy
                                | None -> ()
                                match returnStrategy with
                                | Return -> ()
                                | _ -> PhpBreak None
                            ]

                    ]
                )

            switch1 @ [ switch2 ]

        else
            convertMatching com input guard thenExpr elseExpr expr returnStrategy


    | Fable.IfThenElse(guardExpr, thenExpr, elseExpr, _) ->
        let guard = convertExpr com guardExpr

        [
            PhpIf(
                guard,
                convertExprToStatement com thenExpr returnStrategy,
                convertExprToStatement com elseExpr returnStrategy
            )
        ]
    | Fable.DecisionTreeSuccess(index, boundValues, _) ->
        match returnStrategy with
        | Target target -> [ PhpAssign(PhpVar(target, None), PhpConst(PhpConstNumber(float index))) ]
        | _ ->
            let idents, target = com.DecisionTargets.[index]

            [
                for ident, boundValue in List.zip idents boundValues do
                    com.AddLocalVar(fixName ident.Name, ident.IsMutable)

                    PhpAssign(PhpVar(fixName ident.Name, None), convertExpr com boundValue)
                yield! convertExprToStatement com target returnStrategy
            ]

    | Fable.Let(ident, expr, body) ->
        [
            let name = fixName ident.Name
            com.AddLocalVar(name, ident.IsMutable)
            yield! convertExprToStatement com expr (Let name)
            yield! convertExprToStatement com body returnStrategy
        ]

    | Fable.Sequential(exprs) ->
        if List.isEmpty exprs then
            []
        else
            [
                for expr in exprs.[0 .. exprs.Length - 2] do
                    yield! convertExprToStatement com expr Do
                yield! convertExprToStatement com exprs.[exprs.Length - 1] returnStrategy
            ]
    | Fable.Set(expr, kind, _typ, value, _) ->
        let left = convertExpr com expr

        let leftAssign =
            match kind with
            | Fable.SetKind.ValueSet ->
                match left with
                | PhpVar(v, _) -> com.AddLocalVar(v, true)
                | _ -> ()

                left
            | Fable.SetKind.FieldSet(fieldName) -> PhpField(left, Prop.StrField fieldName, None)
            | Fable.SetKind.ExprSet(keyExpr) -> PhpArrayAccess(left, convertExpr com keyExpr)


        [ PhpAssign(leftAssign, convertExpr com value) ]
    | Fable.TryCatch(body, catch, finallizer, _) ->
        [
            PhpTryCatch(
                convertExprToStatement com body returnStrategy,
                (match catch with
                 | Some(id, expr) -> Some(id.DisplayName, convertExprToStatement com expr returnStrategy)
                 | None -> None),
                match finallizer with
                | Some expr -> convertExprToStatement com expr returnStrategy
                | None -> []
            )
        ]

    | Fable.WhileLoop(guard, body, _) ->
        com.EnterBreakable None
        let phpGuard = convertExpr com guard
        let phpBody = convertExprToStatement com body Do
        com.LeaveBreakable()
        [ PhpWhileLoop(phpGuard, phpBody) ]
    | Fable.ForLoop(ident, start, limit, body, isUp, _) ->
        com.EnterBreakable None
        let id = fixName ident.Name
        let startExpr = convertExpr com start
        com.AddLocalVar(id, false)
        let limitExpr = convertExpr com limit
        let bodyExpr = convertExprToStatement com body Do
        com.LeaveBreakable()

        [ PhpFor(id, startExpr, limitExpr, isUp, bodyExpr) ]

    | Fable.Extended(Fable.Debugger, _) ->
        [
            PhpDo(PhpFunctionCall(PhpIdent(unscopedIdent "assert"), [ PhpConst(PhpConstBool false) ]))
        ]
    | Fable.Extended(Fable.Throw(expr, _), _) ->
        match expr with
        | None -> failwith "TODO: rethrow"
        | Some(Fable.Call(Fable.IdentExpr expr, args, _, _)) when expr.Name = "Error" ->
            [
                PhpThrow(
                    PhpNew(
                        ExType
                            {
                                Name = "Exception"
                                Namespace = Some ""
                                Class = None
                            },
                        List.map (convertExpr com) args.Args
                    )
                )
            ]
        | Some expr -> [ PhpThrow(convertExpr com expr) ]
    | Fable.Extended(Fable.Curry(expr, arrity), _) -> failwith "Curry is not implemented"

    | _ ->
        match returnStrategy with
        | Return -> [ PhpStatement.PhpReturn(convertExpr com expr) ]
        | Let(var) ->
            com.AddLocalVar(var, false)
            [ PhpAssign(PhpVar(var, None), convertExpr com expr) ]
        | Do -> [ PhpStatement.PhpDo(convertExpr com expr) ]
        | Target _ -> failwithf "Target should be assigned by decisiontree success"


let convertMemberDecl (com: IPhpCompiler) (decl: Fable.MemberDecl) =

    let name = fixName decl.Name //.Substring(typ.Name.Length + 2) |> fixName

    let info = com.GetMember(decl.MemberRef)

    if info.IsInstance then
        com.SetThisArgument(fixName decl.Args.[0].Name)

    let body = convertExprToStatement com decl.Body Return
    com.ClearThisArgument()

    {
        PhpFun.Name = fixName name
        PhpFun.Args =
            [
                for arg in decl.Args.[1..] do
                    match arg.Type with
                    | Fable.Unit -> ()
                    | _ -> fixName arg.Name
            ]
        PhpFun.Matchings = []
        PhpFun.Static = not info.IsInstance
        PhpFun.Body = body
    }


let convertDecl (com: IPhpCompiler) decl =
    match decl with
    | Fable.Declaration.ClassDeclaration decl ->
        let ent = com.GetEntity(decl.Entity)
        let name = fixName decl.Name
        com.AddEntityName(ent, name)

        let phpType, extraTypes =
            if ent.IsFSharpUnion then
                convertUnion com decl ent
            elif ent.IsFSharpRecord then
                convertRecord com decl ent
            else
                let baseType =
                    ent.BaseType
                    |> Option.bind (fun b ->
                        match com.TryFindType(b.Entity) with
                        | Ok t -> Some t
                        | Error _ -> None
                    )

                let phpCtor =
                    decl.Constructor
                    |> Option.map (fun ctor ->
                        let rec simplifyCtor expr =
                            match expr with
                            | Fable.Sequential(Fable.ObjectExpr([], _, _) :: rest) ->
                                simplifyCtor (Fable.Sequential rest)
                            | Fable.Sequential(Fable.Value(Fable.UnitConstant, _) :: rest) ->
                                simplifyCtor (Fable.Sequential rest)
                            | _ -> expr

                        {
                            Args =
                                [
                                    for arg in ctor.Args do
                                        fixName arg.Name
                                ]
                            Body = convertExprToStatement com (simplifyCtor ctor.Body) Do
                        }


                    )

                let typ =
                    {
                        Namespace = Some com.PhpNamespace
                        Name = name
                        Fields =
                            [
                                for field in ent.FSharpFields do
                                    {
                                        Name = field.Name
                                        Type = ""
                                    }
                            ]
                        Constructor = phpCtor
                        Methods = []
                        Abstract = false
                        BaseType = baseType
                        Interfaces = []
                        File = com.CurrentFile
                        OriginalFullName = ent.FullName
                    }

                typ, []

        let phpMembers =
            [
                for mdecl in decl.AttachedMembers do
                    convertMemberDecl com mdecl
            ]

        let phpType = { phpType with Methods = phpType.Methods @ phpMembers }

        com.AddType(Some ent.Ref, phpType)

        [
            PhpType phpType
            for t in extraTypes do
                PhpType t
        ]
    | Fable.Declaration.MemberDeclaration decl ->
        let info = com.GetMember(decl.MemberRef)
        com.AddImport(decl.Name, info.IsValue)

        if info.IsValue then
            [ PhpDeclValue(fixName decl.Name, convertExpr com decl.Body) ]
        else
            let body = convertExprToStatement com decl.Body Return

            [
                {
                    PhpFun.Name = fixName decl.Name
                    Args =
                        [
                            for arg in decl.Args do
                                fixName arg.Name
                        ]
                    Matchings = []
                    Body = body
                    Static = false

                }
                |> PhpFun
            ]

    | Fable.Declaration.ActionDeclaration decl -> [ PhpAction(convertExprToStatement com decl.Body Do) ]
    | Fable.ModuleDeclaration decl -> failwith "Not implemented"


type Scope =
    {
        mutable capturedVars: Capture Set
        mutable localVars: string Set
        mutable mutableVars: string Set
        parent: Scope option
    }


    static member create(parent) =
        {
            capturedVars = Set.empty
            localVars = Set.empty
            mutableVars = Set.empty
            parent = parent
        }


type PhpCompiler(com: Fable.Compiler) =
    let mutable types = Map.empty
    let mutable decisionTargets = []
    let mutable scope = Scope.create (None)
    let mutable id = 0
    let mutable isImportValue = Map.empty
    let mutable classNames = Map.empty
    let mutable basePath = ""
    let mutable require = Set.empty
    let mutable nsUse = Set.empty
    let mutable phpNamespace = ""
    let mutable thisArgument = None
    let mutable breakable = []

    member this.AddType(entref: Fable.EntityRef option, phpType: PhpType) =
        let name =
            match entref with
            | Some entref ->
                let ent = com.GetEntity(entref)
                ent.FullName
            | None -> phpType.Name

        types <- Map.add name phpType types

    member this.AddLocalVar(var, isMutable) =
        if isMutable then
            scope.mutableVars <- Set.add var scope.mutableVars

        if scope.capturedVars.Contains(Capture.ByRef var) then
            ()
        elif scope.capturedVars.Contains(Capture.ByValue var) then
            scope.capturedVars <- scope.capturedVars |> Set.remove (Capture.ByValue var) |> Set.add (ByRef var)
        else
            scope.localVars <- Set.add var scope.localVars

    member this.UseVar(var) =
        if
            not (Set.contains var scope.localVars)
            && not (Set.contains (ByRef var) scope.capturedVars)
        then
            if Set.contains var scope.mutableVars then
                scope.capturedVars <- Set.add (ByRef var) scope.capturedVars
            else
                scope.capturedVars <- Set.add (ByValue var) scope.capturedVars


    member this.UseVarByRef(var) =
        scope.mutableVars <- Set.add var scope.mutableVars

        if
            not (Set.contains var scope.localVars)
            && not (Set.contains (ByRef var) scope.capturedVars)
        then
            scope.capturedVars <- Set.add (ByRef var) (Set.remove (ByValue var) scope.capturedVars)

    member this.UseVar(var) =
        match var with
        | ByValue name -> this.UseVar name
        | ByRef name -> this.UseVarByRef name

    member this.MakeUniqueVar(name) =
        id <- id + 1
        "_" + name + "__" + string<int> id

    member this.NewScope() =
        let oldScope = scope
        scope <- Scope.create (Some oldScope)

    member this.RestoreScope() =
        match scope.parent with
        | Some p ->
            let vars = scope.capturedVars
            scope <- p

            for capturedVar in vars do
                this.UseVar(capturedVar)

            Set.toList vars

        | None -> failwith "Already at top scope"

    member this.AddImport(name, isValue) =
        isImportValue <- Map.add name isValue isImportValue

    member this.AddEntityName(entity: Fable.Entity, name) =
        classNames <- Map.add entity.FullName name classNames

    member this.GetEntityName(e: Fable.Entity) =
        match Map.tryFind e.FullName classNames with
        | Some n -> n
        | None -> e.DisplayName

    member this.AddRequire(file: string) =

        if file.Contains "fable-library" then
            let path = Path.GetFileName(fixExt file)
            require <- Set.add (Some "__FABLE_LIBRARY__", "/" + path) require

        else
            let fullPhpPath p =
                if Path.IsPathRooted p then
                    p
                else
                    Path.GetFullPath(Path.Combine(Path.GetDirectoryName(com.CurrentFile), p))

            if fullPhpPath file <> com.CurrentFile then
                let path =
                    let p = Path.getRelativePath basePath (fullPhpPath (fixExt file))

                    if p.StartsWith("./", StringComparison.Ordinal) then
                        p.Substring 2
                    else
                        p

                require <- Set.add (Some "__ROOT__", "/" + path) require

    member this.AddRequire(typ: PhpType) = this.AddRequire(typ.File)

    member this.ClearRequire(path) =
        basePath <- path
        require <- Set.empty
        nsUse <- Set.empty

    member this.AddUse(typ: PhpType) =
        this.AddRequire(typ)
        nsUse <- Set.add typ nsUse

    member this.SetPhpNamespace(ns) = phpNamespace <- ns

    member this.TryFindType(name: string) = Map.tryFind name types

    member this.TryFindType(ref: Fable.EntityRef) =
        let ent = com.GetEntity(ref)

        match this.TryFindType(ent.FullName) with
        | Some t -> Ok t
        | None -> Error ent

    member this.IsThisArgument(id: Fable.Ident) =
        if id.IsThisArgument then
            true
        else
            let name = fixName id.Name

            if Some name = thisArgument then
                true
            else
                false


    member this.IsImport(name: string) = Map.tryFind name isImportValue


    interface IPhpCompiler with
        member this.AddType(entref, phpType: PhpType) = this.AddType(entref, phpType)

        member this.AddLocalVar(var, isMutable) = this.AddLocalVar(var, isMutable)

        member this.UseVar(var: Capture) = this.UseVar(var)
        member this.UseVarByRef(var) = this.UseVarByRef(var)
        member this.UseVar(var: string) = this.UseVar(var)
        member this.MakeUniqueVar(name) = this.MakeUniqueVar(name)
        member this.NewScope() = this.NewScope()
        member this.RestoreScope() = this.RestoreScope()
        member this.AddImport(name, isValue) = this.AddImport(name, isValue)
        member this.IsImport(name) = this.IsImport(name)

        member this.AddEntityName(entity: Fable.Entity, name) = this.AddEntityName(entity, name)

        member this.GetEntityName(e: Fable.Entity) = this.GetEntityName(e)
        member this.AddRequire(file: string) = this.AddRequire(file)
        member this.AddRequire(typ: PhpType) = this.AddRequire(typ)
        member this.ClearRequire(path) = this.ClearRequire(path)
        member this.AddUse(typ: PhpType) = this.AddUse(typ)
        member this.SetPhpNamespace(ns) = this.SetPhpNamespace(ns)

        member this.TryFindType(entity: Fable.EntityRef) = this.TryFindType(entity)

        member this.TryFindType(name: string) = this.TryFindType(name)
        member this.IsThisArgument(id) = this.IsThisArgument(id)
        member this.DecisionTargets = decisionTargets
        member this.SetDecisionTargets value = decisionTargets <- value
        member this.SetThisArgument value = thisArgument <- Some value
        member this.ClearThisArgument() = thisArgument <- None
        member this.PhpNamespace = phpNamespace
        member this.Require = Set.toList require
        member this.NsUse = Set.toList nsUse

        member this.IncrementCounter() = com.IncrementCounter()

        member this.IsPrecompilingInlineFunction = com.IsPrecompilingInlineFunction

        member this.WillPrecompileInlineFunction(file) = com.WillPrecompileInlineFunction(file)

        member this.AddLog(msg, severity, rang, fileName, tag) =
            com.AddLog(msg, severity, ?range = rang, ?fileName = fileName, ?tag = tag)

        member this.AddWatchDependency(file) = com.AddWatchDependency(file)

        member this.GetImplementationFile(fileName) = com.GetImplementationFile(fileName)

        member this.TryGetEntity(entRef) = com.TryGetEntity(entRef)
        member this.GetInlineExpr(fullName) = com.GetInlineExpr(fullName)
        member this.LibraryDir = com.LibraryDir
        member this.CurrentFile = com.CurrentFile
        member this.OutputDir = com.OutputDir
        member this.OutputType = com.OutputType
        member this.ProjectFile = com.ProjectFile
        member this.ProjectOptions = com.ProjectOptions
        member this.SourceFiles = com.SourceFiles
        member this.Options = com.Options
        member this.Plugins = com.Plugins
        member this.GetRootModule(fileName) = com.GetRootModule(fileName)
        member this.EnterBreakable(label) = breakable <- label :: breakable
        member this.LeaveBreakable() = breakable <- List.tail breakable

        member this.FindLableLevel(label) =
            List.findIndex
                (function
                | Some v when v = label -> true
                | _ -> false)
                breakable

module Compiler =

    let transformFile com (file: Fable.File) =
        let phpComp = PhpCompiler(com) :> IPhpCompiler
        phpComp.ClearRequire(__SOURCE_DIRECTORY__ + @"/src/")

        let rootModule = com.GetRootModule(phpComp.CurrentFile) |> fst |> nsreplacement
        phpComp.SetPhpNamespace(rootModule)

        let decls =
            [
                for i, decl in List.indexed file.Declarations do
                    let decls =
                        try
                            convertDecl phpComp decl
                        with ex ->
                            eprintfn "Error while transpiling decl %d: %O" i ex
                            reraise ()

                    for d in decls do
                        i, d
            ]

        {
            Filename = phpComp.CurrentFile + ".php"
            Namespace = Some phpComp.PhpNamespace
            Require = phpComp.Require
            Uses = phpComp.NsUse
            Decls = decls
        }
